#!/usr/bin/env python3
# Copyright lowRISC contributors.
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
# SPDX-License-Identifier: Apache-2.0

import argparse
import fnmatch
import logging as log
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path

import hjson

DESC = """vendor_hw, copy hardware source code from upstream into this repository"""

EXCLUDE_ALWAYS = ['.git']

LOCK_FILE_HEADER = """// Copyright lowRISC contributors.
// Licensed under the Apache License, Version 2.0, see LICENSE for details.
// SPDX-License-Identifier: Apache-2.0

// This file is generated by the vendor_hw script. Please do not modify it
// manually.

"""

verbose = False


def git_is_clean_workdir(git_workdir):
    """Check if the git working directory is clean (no unstaged or staged changes)"""
    cmd = ['git', 'status', '--untracked-files=no', '--porcelain']
    modified_files = subprocess.run(cmd,
                                    cwd=git_workdir,
                                    check=True,
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE).stdout.strip()
    return not modified_files


def path_resolve(path, base_dir=Path.cwd()):
    """Create an absolute path. Relative paths are resolved using base_dir as base."""

    if isinstance(path, str):
        path = Path(path)

    if path.is_absolute():
        return path

    return (base_dir / path).resolve()


def github_qualify_references(log, repo_userorg, repo_name):
    """ Replace "unqualified" GitHub references with "fully qualified" one

    GitHub automatically links issues and pull requests if they have a specific
    format. Links can be qualified with the user/org name and the repository
    name, or unqualified, if they only contain the issue or pull request number.

    This function converts all unqualified references to qualified ones.

    See https://help.github.com/en/articles/autolinked-references-and-urls#issues-and-pull-requests
    for a documentation of all supported formats.
    """

    r = re.compile(r"(^|[^\w])(?:#|[gG][hH]-)(\d+)\b")
    repl_str = r'\1%s/%s#\2' % (repo_userorg, repo_name)
    return [r.sub(repl_str, l) for l in log]


def test_github_qualify_references():
    repo_userorg = 'lowRISC'
    repo_name = 'ibex'

    # Unqualified references, should be replaced
    items_unqualified = [
        '#28',
        'GH-27',
        'klaus #27',
        'Fixes #27',
        'Fixes #27 and #28',
        '(#27)',
        'something (#27) done',
        '#27 and (GH-38)',
    ]
    exp_items_unqualified = [
        'lowRISC/ibex#28',
        'lowRISC/ibex#27',
        'klaus lowRISC/ibex#27',
        'Fixes lowRISC/ibex#27',
        'Fixes lowRISC/ibex#27 and lowRISC/ibex#28',
        '(lowRISC/ibex#27)',
        'something (lowRISC/ibex#27) done',
        'lowRISC/ibex#27 and (lowRISC/ibex#38)',
    ]
    assert github_qualify_references(items_unqualified, repo_userorg,
                                     repo_name) == exp_items_unqualified

    # Qualified references, should stay as they are
    items_qualified = [
        'Fixes lowrisc/ibex#27',
        'lowrisc/ibex#2',
    ]
    assert github_qualify_references(items_qualified, repo_userorg,
                                     repo_name) == items_qualified

    # Invalid references, should stay as they are
    items_invalid = [
        'something#27',
        'lowrisc/ibex#',
    ]
    assert github_qualify_references(items_invalid, repo_userorg,
                                     repo_name) == items_invalid


def test_github_parse_url():
    assert github_parse_url('https://example.com/something/asdf.git') is None
    assert github_parse_url('https://github.com/lowRISC/ibex.git') == (
        'lowRISC', 'ibex')
    assert github_parse_url('https://github.com/lowRISC/ibex') == ('lowRISC',
                                                                   'ibex')
    assert github_parse_url('git@github.com:lowRISC/ibex.git') == ('lowRISC',
                                                                   'ibex')


def github_parse_url(github_repo_url):
    """Parse a GitHub repository URL into its parts.

    Return a tuple (userorg, name), or None if the parsing failed.
    """

    regex = r"(?:@github\.com\:|\/github\.com\/)([a-zA-Z\d-]+)\/([a-zA-Z\d-]+)(?:\.git)?$"
    m = re.search(regex, github_repo_url)
    if m is None:
        return None
    return (m.group(1), m.group(2))


def produce_shortlog(clone_dir, old_rev, new_rev):
    """ Produce a list of changes between two revisions, one revision per line

    Merges are excluded"""
    cmd = [
        'git', '-C',
        str(clone_dir), 'log', '--pretty=format:%s (%aN)', '--no-merges',
        old_rev + '..' + new_rev, '.'
    ]
    try:
        proc = subprocess.run(cmd,
                              cwd=clone_dir,
                              check=True,
                              stdout=subprocess.PIPE,
                              stderr=subprocess.PIPE,
                              encoding="UTF-8")
        return proc.stdout.splitlines()
    except subprocess.CalledProcessError as e:
        log.error("Unable to capture shortlog: %s", e.stderr)
        return ""


def format_list_to_str(list, width=70):
    """ Create Markdown-style formatted string from a list of strings """
    wrapper = textwrap.TextWrapper(initial_indent="* ",
                                   subsequent_indent="  ",
                                   width=width)
    return '\n'.join([wrapper.fill(s) for s in list])


def refresh_patches(desc):
    if not 'patch_repo' in desc:
        log.fatal('Unable to refresh patches, patch_repo not set in config.')
        sys.exit(1)

    patch_dir_abs = path_resolve(desc['patch_dir'], desc['_base_dir'])
    log.info('Refreshing patches in %s' % (str(patch_dir_abs), ))

    # remove existing patches
    for patch in patch_dir_abs.glob('*.patch'):
        os.unlink(str(patch))

    # get current patches
    _export_patches(desc['patch_repo']['url'], patch_dir_abs,
                    desc['patch_repo']['rev_base'],
                    desc['patch_repo']['rev_patched'])


def _export_patches(patchrepo_clone_url, target_patch_dir, upstream_rev,
                    patched_rev):
    clone_dir = Path(tempfile.mkdtemp())
    try:
        clone_git_repo(patchrepo_clone_url, clone_dir, patched_rev)
        rev_range = 'origin/' + upstream_rev + '..' + 'origin/' + patched_rev
        cmd = ['git', 'format-patch', '-o', str(target_patch_dir), rev_range]
        if not verbose:
            cmd += ['-q']
        subprocess.run(cmd, cwd=clone_dir, check=True)

    finally:
        shutil.rmtree(str(clone_dir), ignore_errors=True)


def import_from_upstream(upstream_path, target_path, exclude_files=[]):
    log.info('Copying upstream sources to %s', target_path)
    # remove existing directories before importing them again
    shutil.rmtree(str(target_path), ignore_errors=True)

    # import new contents for rtl directory
    _cp_from_upstream(upstream_path, target_path, exclude_files)


def apply_patch(basedir, patchfile, strip_level=1):
    cmd = ['git', 'apply', '-p' + str(strip_level), patchfile]
    if verbose:
        cmd += ['--verbose']
    subprocess.run(cmd, cwd=basedir, check=True)


def clone_git_repo(repo_url, clone_dir, rev='master'):
    log.info('Cloning upstream repository %s @ %s', repo_url, rev)

    cmd = [
        'git', 'clone', '--no-single-branch', '-b', rev, repo_url, clone_dir
    ]
    if not verbose:
        cmd += ['-q']
    subprocess.run(cmd, check=True)

    # Get revision information
    cmd = ['git', '-C', str(clone_dir), 'rev-parse', 'HEAD']
    rev = subprocess.run(cmd,
                         stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE,
                         check=True,
                         encoding='UTF-8').stdout.strip()
    log.info('Cloned at revision %s', rev)
    return rev


def git_get_short_rev(clone_dir, rev):
    """ Get the shortened SHA-1 hash for a revision """
    cmd = ['git', '-C', str(clone_dir), 'rev-parse', '--short', rev]
    short_rev = subprocess.run(cmd,
                               stdout=subprocess.PIPE,
                               stderr=subprocess.PIPE,
                               check=True,
                               encoding='UTF-8').stdout.strip()
    return short_rev


def git_add_commit(repo_base, paths, commit_msg):
    """ Stage and commit all changes in paths"""

    # Stage all changes
    for p in paths:
        cmd_add = ['git', '-C', str(repo_base), 'add', str(p)]
        subprocess.run(cmd_add, check=True, encoding='UTF-8')

    cmd_commit = ['git', '-C', str(repo_base), 'commit', '-F', '-']
    try:
        subprocess.run(cmd_commit,
                       check=True,
                       encoding='UTF-8',
                       input=commit_msg)
    except subprocess.CalledProcessError as e:
        log.warning("Unable to create commit. Are there no changes?")


def ignore_patterns(base_dir, *patterns):
    """Similar to shutil.ignore_patterns, but with support for directory excludes."""
    def _rel_to_base(path, name):
        return os.path.relpath(os.path.join(path, name), base_dir)

    def _ignore_patterns(path, names):
        ignored_names = []
        for pattern in patterns:
            pattern_matches = [
                n for n in names
                if fnmatch.fnmatch(_rel_to_base(path, n), pattern)
            ]
            ignored_names.extend(pattern_matches)
        return set(ignored_names)

    return _ignore_patterns


def _cp_from_upstream(src, dest, exclude=[]):
    shutil.copytree(str(src),
                    str(dest),
                    ignore=ignore_patterns(str(src), *exclude))


def main(argv):
    parser = argparse.ArgumentParser(prog="vendor_hw", description=DESC)
    parser.add_argument('--refresh-patches',
                        action='store_true',
                        help='Refresh the patches from the patch repository')
    parser.add_argument('--commit',
                        '-c',
                        action='store_true',
                        help='Commit the changes')
    parser.add_argument('desc_file',
                        metavar='file',
                        type=argparse.FileType('r', encoding='UTF-8'),
                        help='vendoring description file (*.vendor.hjson)')
    parser.add_argument('--verbose', '-v', action='store_true', help='Verbose')
    args = parser.parse_args()

    global verbose
    verbose = args.verbose
    if (verbose):
        log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG)
    else:
        log.basicConfig(format="%(levelname)s: %(message)s")

    desc_file_path = Path(args.desc_file.name).resolve()
    vendor_file_base_dir = desc_file_path.parent

    # Precondition: Ensure description file matches our naming rules
    if not str(desc_file_path).endswith('.vendor.hjson'):
        log.fatal("Description file names must have a .vendor.hjson suffix.")
        raise SystemExit(1)

    # Precondition: Check for a clean working directory when commit is requested
    if args.commit:
        if not git_is_clean_workdir(vendor_file_base_dir):
            log.fatal("A clean git working directory is required for "
                      "--commit/-c. git stash your changes and try again.")
            raise SystemExit(1)

    # Load description file
    try:
        desc = hjson.loads(args.desc_file.read(), use_decimal=True)
    except ValueError:
        raise SystemExit(sys.exc_info()[1])
    desc['_base_dir'] = vendor_file_base_dir

    # Load lock file contents (if possible)
    desc_file_path_str = str(desc_file_path)
    lock_file_path = Path(
        desc_file_path_str[:desc_file_path_str.find('.vendor.hjson')] +
        '.lock.hjson')
    try:
        with open(lock_file_path, 'r') as f:
            lock = hjson.loads(f.read(), use_decimal=True)
    except FileNotFoundError:
        log.warning(
            "Unable to read lock file %s. Assuming this is the first import.",
            lock_file_path)
        lock = None

    if args.refresh_patches:
        refresh_patches(desc)

    clone_dir = Path(tempfile.mkdtemp())
    try:
        # clone upstream repository
        upstream_new_rev = clone_git_repo(desc['upstream']['url'], clone_dir,
                                          desc['upstream']['rev'])

        upstream_only_subdir = ''
        clone_subdir = clone_dir
        if 'only_subdir' in desc['upstream']:
            upstream_only_subdir = desc['upstream']['only_subdir']
            clone_subdir = clone_dir / upstream_only_subdir
            if not clone_subdir.is_dir():
                log.fatal("subdir '%s' does not exist in repo", upstream_only_subdir)
                raise SystemExit(1)


        # apply patches to upstream sources
        if 'patch_dir' in desc:
            patches = path_resolve(desc['patch_dir'],
                                   vendor_file_base_dir).glob('*.patch')
            for patch in sorted(patches):
                log.info("Applying patch %s" % str(patch))
                apply_patch(clone_subdir, str(patch))

        # import selected (patched) files from upstream repo
        exclude_files = []
        if 'exclude_from_upstream' in desc:
            exclude_files += desc['exclude_from_upstream']
        exclude_files += EXCLUDE_ALWAYS

        import_from_upstream(
            clone_subdir, path_resolve(desc['target_dir'], vendor_file_base_dir),
            exclude_files)

        # get shortlog
        get_shortlog = True
        if not lock:
            get_shortlog = False
            log.warning(
                "No lock file exists. Unable to get the log of changes.")
        elif lock['upstream']['url'] != desc['upstream']['url']:
            get_shortlog = False
            log.warning(
                "The repository URL changed since the last run. Unable to get log of changes."
            )
        elif upstream_new_rev == lock['upstream']['rev']:
            get_shortlog = False
            log.warning("Re-importing upstream revision %s", upstream_new_rev)

        shortlog = None
        if get_shortlog:
            shortlog = produce_shortlog(clone_subdir, lock['upstream']['rev'],
                                        upstream_new_rev)

            # Ensure fully-qualified issue/PR references for GitHub repos
            gh_repo_info = github_parse_url(desc['upstream']['url'])
            if gh_repo_info:
                shortlog = github_qualify_references(shortlog, gh_repo_info[0],
                                                     gh_repo_info[1])

            log.info("Changes since the last import:\n" +
                     format_list_to_str(shortlog))

        # write lock file
        lock = {}
        lock['upstream'] = desc['upstream']
        lock['upstream']['rev'] = upstream_new_rev
        with open(lock_file_path, 'w', encoding='UTF-8') as f:
            f.write(LOCK_FILE_HEADER)
            hjson.dump(lock, f)
            f.write("\n")
            log.info("Wrote lock file %s", lock_file_path)

        # Commit changes
        if args.commit:
            sha_short = git_get_short_rev(clone_subdir, upstream_new_rev)

            repo_info = github_parse_url(desc['upstream']['url'])
            if repo_info is not None:
                sha_short = "%s/%s@%s" % (repo_info[0], repo_info[1],
                                          sha_short)

            commit_msg_subject = 'Update %s to %s' % (desc['name'], sha_short)
            subdir_msg = ' '
            if upstream_only_subdir:
                subdir_msg = ' subdir %s in ' % upstream_only_subdir
            intro = 'Update code from%supstream repository %s to revision %s' % (
                subdir_msg, desc['upstream']['url'], upstream_new_rev)
            commit_msg_body = textwrap.fill(intro, width=70)

            if shortlog:
                commit_msg_body += "\n\n"
                commit_msg_body += format_list_to_str(shortlog, width=70)

            commit_msg = commit_msg_subject + "\n\n" + commit_msg_body

            commit_paths = []
            commit_paths.append(
                path_resolve(desc['target_dir'], vendor_file_base_dir))
            if args.refresh_patches:
                commit_paths.append(
                    path_resolve(desc['patch_dir'], vendor_file_base_dir))
            commit_paths.append(lock_file_path)

            git_add_commit(vendor_file_base_dir, commit_paths, commit_msg)

    finally:
        shutil.rmtree(str(clone_dir), ignore_errors=True)

    log.info('Import finished')


if __name__ == '__main__':
    try:
        main(sys.argv)
    except subprocess.CalledProcessError as e:
        log.fatal("Called program '%s' returned with %d.\n"
                  "STDOUT:\n%s\n"
                  "STDERR:\n%s\n" %
                  (" ".join(e.cmd), e.returncode, e.stdout, e.stderr))
        raise
